函数的副作用就是函数除了返回值外,对外界环境造成的其它影响,即与组件渲染无关的操作。例如获取数据、修改全局变量、更新 DOM 等。
useEffect 是 React 中的 hooks API。通过 useEffect 可以执行一些副作用操作,例如:请求数据、事件监听等。它的语法格式如下:
xxxxxxxxxx11useEffect(fn, deps?)其中,
第一个参数 fn 是一个副作用函数,该函数会在每次渲染完成之后被调用。
第二个参数是可选的依赖项数组,这个数组中的每一项内容都会被用来进行渲染前后的对比
如果没有为 useEffect 指定依赖项数组,则 Effect 中的副作用函数,会在函数组件每次渲染完成后执行。例如,我们在下面的代码中,基于 useEffect 获取 h1 元素最新的 innerHTML:
xxxxxxxxxx241import React, { useEffect, useState } from 'react'23export const Counter: React.FC = () => {4 const [count, setCount] = useState(0)56 // 注意:这里每次输出的都是上一次的旧值7 // console.log(document.querySelector('h1')?.innerHTML)89 const add = () => {10 setCount((prev) => prev + 1)11 }1213 // 在组件每次渲染完成之后,都会重新执行 effect 中的回调函数。为什么知道是每次渲染完成之后才执行useEffect里面的回调函数呢?因为输出的DOM内容是最新的。14 useEffect(() => {15 console.log('useEffect ',document.querySelector('h1')?.innerHTML)16 })1718 return (19 <>20 <h1>count 值为:{count}</h1>21 <button onClick={add}>+1</button>22 </>23 )24}
如果为 useEffect 指定了一个空数组 [] 作为 deps 依赖项,则副作用函数只会在组件首次渲染完成后执行唯一的一次。当组件 rerender 的时候不会触发副作用函数的重新执行。例如下面的代码中,useEffect 中的 console.log() 只会执行1次:
xxxxxxxxxx231import React, { useEffect, useState } from 'react'23export const Counter: React.FC = () => {4 const [count, setCount] = useState(0)56 console.log(document.querySelector('h1')?.innerHTML)7 8 const add = () => {9 setCount((prev) => prev + 1)10 }1112 // 仅在组件首次渲染完成后,会执行 effect 中的回调函数13 useEffect(() => {14 console.log('useEffect ', document.querySelector('h1')?.innerHTML)15 }, [])1617 return (18 <>19 <h1>count 值为:{count}</h1>20 <button onClick={add}>+1</button>21 </>22 )23}
如果想有条件地触发副作用函数的重新执行,则需要通过 deps 数组指定依赖项列表。
React 会在组件每次渲染完成后,对比渲染前后的每一个依赖项是否发生了变化,只要任何一个依赖项发生了变化,都会触发副作用函数的重新执行。否则,如果所有依赖项在渲染前后都没有发生变化,则不会触发副作用函数的重新执行。
在一个useEffect里面,deps数组里面建议只写一个依赖项,因为如果写多个的话,可能造成不必要的重新渲染。
一个组件里面可以写多个useEffect,来达到监听不同状态的目的。
下面的例子演示了依赖项的使用:只有当 count 值发生变化时,才会触发 useEffect 回调函数的重新执行,flag 值的变化不会触发:
xxxxxxxxxx251import React, { useEffect, useState } from 'react'23export const Counter: React.FC = () => {4 const [count, setCount] = useState(0)5 const [flag, setFlag] = useState(false)6 7 const add = () => {8 setCount((prev) => prev + 1)9 }1011 // 在组件每次渲染完成后,如果 count 值发生了变化,则执行 effect 中的回调12 // 其它状态的变化,不会导致此回调函数的重新执行13 useEffect(() => {14 console.log('useEffect ', document.querySelector('h1')?.innerHTML)15 }, [count])1617 return (18 <>19 <h1>count 值为:{count}</h1>20 <p>flag 的值为:{String(flag)}</p>21 <button onClick={add}>+1</button>22 <button onClick={() => setFlag((prev) => !prev)}>Toggle</button>23 </>24 )25}
注意:不建议把对象作为 useEffect 的依赖项,因为 React 使用 Object.is() 来判断依赖项是否发生变化。
使用props传递过来的数据可以使用useEffect进行监听。
例如:
那如何监听对象、数组呢?
暂时还不知道,不确定是不是
useReducer,等后面知道答案了再来回答吧。
useEffect 可以返回一个函数,用于清除副作用的回调。语法格式如下:
xxxxxxxxxx61useEffect(() => {2 // 1. 执行副作用操作3 4 // 2. 返回一个清理副作用的函数5 return () => { /* 在这里执行自己的清理操作 */ }6}, [依赖项])这里有一点刚开始不太明白,为什么要清理副作用呢?
因为副作用一般都是定时器函数或者事件监听程序,如果不清理,即使页面切换了,这些函数或程序还是会一直执行下去,产生意想不到的影响。
什么叫“清理副作用”?怎么清理?
其实就是让副作用不再起作用,副作用是自己定义的,要么是定时器、要么是事件监听函数,该怎么清除自己也应该知道。这个函数是怎么执行的呢?是react接收到了清理副作用的函数,react会调用这个函数的。
清理函数触发的时机:
xxxxxxxxxx31清理副作用函数触发的时机有两个:21. 组件被卸载的时候,会调用32. 当 useEffect 副作用函数被再次执行之前,会先执行清理副作用函数
实际应用场景:如果当前组件中使用了定时器或绑定了事件监听程序,可以在返回的函数中清除定时器或解绑监听程序。
在父组件 TestRandomColor 中,使用布尔值 flag 控制子组件 RandomColor 的展示与隐藏,隐藏组件就表示卸载组件:
xxxxxxxxxx111export const TestRandomColor: React.FC = () => {2 const [flag, setFlag] = useState(true)34 return (5 <>6 <button onClick={() => setFlag((prev) => !prev)}>Toggle</button>7 <hr />8 {flag && <RandomColor />}9 </>10 )11}在子组件 RandomColor 中,通过 useEffect(fn, []) 声明一个副作用函数,该副作用函数仅在组件首次渲染完毕后执行。在该副作用函数中,基于 fetch API 请求数据,并且在清理函数中使用 AbortController 对象自动终止未完成的 Ajax 请求。
AbortController 对象是一个JS API,可以用来取消 Fetch 请求,它的 signal 属性指向一个 AbortSignal 对象。AbortSignal 对象可以用来检测 Fetch 请求是否已经被取消,从而停止处理响应数据。
示例代码如下:
xxxxxxxxxx281const RandomColor: React.FC = () => {2 const [color, setColor] = useState('')34 useEffect(() => {5 const controller = new AbortController()67 fetch('https://api.liulongbin.top/v1/color', { signal: controller.signal })8 .then((res) => res.json())9 .then((res) => {10 console.log(res)11 setColor(res.data.color)12 })13 .catch((err) => console.log('消息:' + err.message))1415 // return 清理函数16 // 清理函数触发的时机有两个:17 // 1. 组件被卸载的时候,会调用18 // 2. 当 effect 副作用函数被再次执行之前,会先执行清理函数19 return () => controller.abort()20 // 注意useEffect的deps是空数组,表示只是组件初始化时渲染一次21 }, [])2223 return (24 <>25 <p>color 的颜色值是:{color}</p>26 </>27 )28}正常请求:

在请求开始时,卸载子组件,查看请求是否停止:

可以看到请求终止了。
示例代码如下,先声明一个 MouseInfo 的子组件,用来监听鼠标的移动并打印鼠标的位置:
xxxxxxxxxx251const MouseInfo: React.FC = () => {2 // 记录鼠标的位置3 const [position, setPosition] = useState({ x: 0, y: 0 })45 // 副作用函数6 useEffect(() => {7 // 1. 要绑定或解绑的 mousemove 事件处理函数8 const mouseMoveHandler = (e: MouseEvent) => {9 console.log({ x: e.clientX, y: e.clientY })10 setPosition({ x: e.clientX, y: e.clientY })11 }1213 // 2. 组件首次渲染完毕后,为 window 对象绑定 mousemove 事件14 window.addEventListener('mousemove', mouseMoveHandler)1516 // 3. 返回一个清理的函数,在每次组件卸载时,为 window 对象解绑 mousemove 事件17 return () => window.removeEventListener('mousemove', mouseMoveHandler)18 }, [])1920 return (21 <>22 <p>鼠标的位置:{JSON.stringify(position)}</p>23 </>24 )25}再声明一个 TestMouseInfo 的父组件,通过布尔值 flag 控制子组件 MouseInfo 的显示或隐藏:
xxxxxxxxxx141export const TestMouseInfo: React.FC = () => {2 // 定义布尔值 flag,控制子组件的显示或隐藏3 const [flag, setFlag] = useState(true)45 return (6 <>7 <h3>父组件</h3>8 {/* 点击按钮,切换 flag 的值 */}9 <button onClick={() => setFlag((prev) => !prev)}>Toggle</button>10 <hr />11 {flag && <MouseInfo />}12 </>13 )14}子组件卸载后,移动鼠标,没有输出,说明绑定的事件已经被清除了,也就是执行了清除副作用。

优化:
鼠标的监听没有节流,如果在一个大项目里面,会造成更新太频繁,会很卡顿,所以加上节流是必须的。
xxxxxxxxxx301const MouseInfo:React.FC = () => {2 const [position,setPosition] = useState({x:0,y:0})3 4 useEffect(() => {5 // timerId的类型可能为null或者NodeJS.Timeout类型,至于NodeJS.Timeout这个类型是怎么来的,记住就行了,暂时还不知道该怎么找到。6 // -----2024.02.22 如果不知道某种ts类型该怎么定义,那就先搜索,将相关信息都搜索一下,上面这个问题,我搜索 settimeout的ts类型怎么定义 ,结果就出现了NodeJS.Timeout这个类型信息,很不错。7 let timerId:null | NodeJS.Timeout = null;8 9 const mouseMoveHandler = (e:MouseEvent) => {10 // 如果timerId不为null,就不执行。那什么时候不为null呢?就是上次设置延时操作后,timerId就有值了。然后1000ms之后,会执行赋值操作,并且把timerId设置为null。11 if(timerId !== null) return;12 13 timerId = setTimeout(() => {14 console.log({x:e.clientX,y:e.clientY})15 setPosition({x:e.clientX,y:e.clientY})16 timerId = null17 },1000)18 }19 20 window.addEventListener("mousemove",mouseMoveHandler);21 22 return () => window.removeEventListener("mousemove",mouseMoveHandler)23 },[])24 25 return (26 <>27 <p>鼠标的位置:{JSON.stringify(position)}</p>28 </>29 )30}可以看到,在1000ms之内,鼠标移动了很多的位置,但是只有在1000ms之后的位置,才会更新位置信息。

注意:
如果想自定义延迟时间,需要在父组件中传递参数,在子组件中使用props来接受参数。
不要被自定义hook难住了,更不要以为自定义hook一定要按照useState或其它官方hooks的形式来做,useState的返回值一个是数值,一个是函数,我也非这样做不可,千万不要这么想。
自定义hook就是一个功能函数,完成所需的功能即可,形式可以多种多样。
注意:
不要一开始就想写成一个hook,这还是很难的,最好还是先在子组件里面实现功能,然后将功能代码剪切到定义的hook里面,然后调用hook查看是否能正常使用。
这样写代码才会简单一些。
在 src 目录下新建 hooks/index.ts 模块,并把刚才获取鼠标位置的代码封装成名为 useMousePosition 的自定义 hook,代码如下:
xxxxxxxxxx241import { useState, useEffect } from 'react'23export const useMousePosition = () => {4 // 记录鼠标的位置5 const [position, setPosition] = useState({ x: 0, y: 0 })67 // 副作用函数8 useEffect(() => {9 // 1. 要绑定或解绑的 mousemove 事件处理函数10 const mouseMoveHandler = (e: MouseEvent) => {11 console.log({ x: e.clientX, y: e.clientY })12 setPosition({ x: e.clientX, y: e.clientY })13 }1415 // 2. 组件首次渲染完毕后,为 window 对象绑定 mousemove 事件16 window.addEventListener('mousemove', mouseMoveHandler)1718 // 3. 返回一个清理的函数,在每次组件卸载时,为 window 对象解绑 mousemove 事件19 return () => window.removeEventListener('mousemove', mouseMoveHandler)20 }, [])2122 // 注意:这里就不是return 一个dom结构了,而是返回数据23 return position24}在 MouseInfo 组件中,可以导入自己封装的 hook 进行使用:
xxxxxxxxxx131import { useMousePosition } from '@/hooks/index.ts'23const MouseInfo: React.FC = () => {4 // 调用自定义的 hook,获取鼠标的位置信息5 const position = useMousePosition()67 return (8 <>9 <!-- 输出鼠标的位置信息 -->10 <p>鼠标的位置:{JSON.stringify(position)}</p>11 </>12 )13}在 TestMouseInfo 组件中,也可以导入自己封装的 hook 进行使用:
xxxxxxxxxx171import { useMousePosition } from '@/hooks/index.ts'23export const TestMouseInfo: React.FC = () => {4 const [flag, setFlag] = useState(true)5 // 调用自定义的 hook,获取鼠标的位置信息6 const position = useMousePosition()78 return (9 <>10 <!-- 输出鼠标的位置信息 -->11 <h3>父组件 {position.x + position.y}</h3>12 <button onClick={() => setFlag((prev) => !prev)}>Toggle</button>13 <hr />14 {flag && <MouseInfo />}15 </>16 )17}
添加节流的useMouseInfo:
xxxxxxxxxx221import { useState, useEffect } from "react";23export const useMousePosition = (delay: number = 500) => {4 const [position, setPosition] = useState({ x: 0, y: 0 });56 useEffect(() => {7 let timerId: null | NodeJS.Timeout = null;8 const mouseMoveHandler = (e: MouseEvent) => {9 if (timerId !== null) return;10 timerId = setTimeout(() => {11 setPosition({ x: e.clientX, y: e.clientY });12 timerId = null;13 }, delay);14 };1516 window.addEventListener("mousemove", mouseMoveHandler);1718 return () => window.removeEventListener("mousemove", mouseMoveHandler);19 }, []);2021 return position;22};
学习到这里,其实我有一个疑问?为什么自定义的hooks能够触发组件的重新渲染呢?
因为自定义的hooks里面用到了react官方的hooks,如果没有用到react官方的hooks,那就相当于是一个普通组件或者函数了,就不能称之为“自定义hooks”了。
上面的自定义hooks有一个ts的报错:
看错误提示信息,应该是没有在useEffect的依赖数组里面添加delay这个依赖,但肯定不能加上,也不能去掉依赖项数组。所以最好的方法还是先忽略掉这个ts报错,搜索了一下:https://juejin.cn/post/7133968417404485663
只能先忽略掉这个ts报错,因为别的方法都特别复杂。
功能分析:
useCountDown(5) 的 hook,可以传递倒计时的秒数,如果未指定秒数则默认值为 10 秒useCountDown 中,需要对用户传递进行来的数字进行非法值的判断和处理(处理负数、小数、0)-1,并使用一个布尔值记录按钮是否被禁用return [count, disabled]最终,用户可以按照如下的方式,使用我们封装的 useCountDown hook:
xxxxxxxxxx171import React from 'react'2// 1. 导入自定义的 hook3import { useCountDown } from '@/hooks/index.ts'45export const CountDown: React.FC = () => {6 // 2. 调用自定义的 hook7 const [count, disabled] = useCountDown(3)89 return (10 <>11 <!-- 3. 展示倒计时的秒数,并控制按钮的禁用状态 -->12 <button disabled={disabled} onClick={() => console.log('协议生效!')}>13 {disabled ? `请仔细阅读本协议内容(${count} 秒)` : '确认此协议'}14 </button>15 </>16 )17}上面这种写法很牛逼啊,直接在button标签上就写好了。如果要我使用vue或小程序来写,估计会使用很多变量来控制。
接下来,我们可以在 src/hooks/index.ts 模块中,封装名为 useCountDown 的自定义 hook。具体代码如下:
xxxxxxxxxx371import { useState, useEffect } from 'react'23// TS 类型4type UseCountDown = (seconds: number) => [number, boolean]56export const useCountDown: UseCountDown = (seconds = 10) => {7 // 对外界传递的数值进行非法值处理:8 // 1. 先求绝对值9 // 2. 再对小数进行四舍五入10 // 3. 如果处理的结果为数字 0,则将默认值设为 1011 // 这里因为使用了ts类型规定seconds的类型为number,所以不需要对seconds是别的类型做出判断,在使用这个方法的时候,ts就会给出提示。12 seconds = Math.round(Math.abs(seconds)) || 101314 // 计数器15 const [count, setCount] = useState(seconds)16 // 倒计时是否结束 disabled 为 false 表示结束,为 true 表示未结束17 const [disabled, setDisabled] = useState(true)1819 useEffect(() => {20 const timerId = setTimeout(() => {21 if (count > 1) {22 setCount((prev) => prev - 1)23 } else {24 clearTimeout(timerId)25 setDisabled(false)26 }27 }, 1000)2829 // 返回清理函数,再次执行 useEffect 的副作用函数之前,先运行上次 return 的清理函数30 return () => clearTimeout(timerId)31 }, [count])3233 // 返回 count 和 disabled 供组件使用34 // 1. count 用来显示倒计时的秒数35 // 2. disabled 用来控制按钮是否禁用 Or 倒计时是否结束36 return [count, disabled]37}
注意:
这个倒计时的写法思路要搞清楚,为什么是用setTimeout(),而不是我一想就想到的setInterval()?为什么在else里面要清除定时器,在useEffect里面也要返回清除定时器的函数?
可以使用setInterval(),useEffect()的依赖项数组设置为空数组就行了,但是这个interval的终止条件怎么写呢?可以写在serInterval()里面,试一下:
xxxxxxxxxx101useEffect(()=>{2const intervalId = setInterval(()=>{3if(count > 1){4setCount(prev => prev - 1);5} else {6clearInterval(intervalId);7setDisabled(false);8}9},1000)10},[])一定要检查一下这样的效果怎么样,记录下来。
可以看到在count===0时,计时器并没有停下来,为什么呢?输出看一下:
可以看到interval里面的count值一直没有变化,就是初始值,为什么呢?interval里面的函数不是每1秒钟执行一次吗?执行的时候拿到的不应该是最新值吗?
搜索了一下,setInterval是每隔一段时间,调用一次fn,这个fn是不变的,所以里面的变量也是不变的。那为什么setCount又能改变值呢?因为setCount是异步执行,拿到了最新值。
来看第二个问题,else里面的清除只会执行一次,而useEffect()里面的清除函数,是在每次useEffect()执行之前都会执行,这样timerId才不会重复,setTimeout才会执行下去。
------2024.02.23
上面这段话有错误,setTimeout的timerId不涉及到重复问题,如果不清除也可以正常执行,不会报错。我写成这样也是正常执行没有报错的:
为了避免内存泄漏和不必要的资源消耗,需要在适当的时机清除setTimeout计时器,所以老师的代码中写了。
例子:
xxxxxxxxxx191export const EndlessLoop: React.FC = () => {2 const [count, setCount] = useState(0);34 useEffect(() => {5 setCount((prev) => prev + 1);6 console.log(count);7 }, [count]);89 const add = () => {10 setCount(count + 1);11 };1213 return (14 <>15 <h3>count 的值为:{count}</h3>16 <button onClick={add}>点我+1</button>17 </>18 );19};
注意:
这里的意思很明确了,不要在useEffect中改变依赖项的值。也就是说useEffect的deps是一个数组,并且数组里面有依赖项。前面的例子中,deps都是空数组,意味着只在组件初次渲染的时候执行一次,所以是没有问题的。
千万不要理解错了意思。
同时还要注意一点:在useEffect中改变依赖项的值,如果不写依赖项数组,也会造成死循环。因为不写依赖项数组,则每次组件更新都会执行一次,而执行一次就会改变一次依赖项的值,这样就是死循环。
xxxxxxxxxx61// 这样也会造成死循环,可以测试一下。23useEffect(() => {4setCount((prev) => prev + 1);5console.log(count);6});
例子:
xxxxxxxxxx191export const Counter: React.FC = () => {2 const [count, setCount] = useState(0);3 const [flag, setFlag] = useState(false);45 useEffect(() => {6 console.log("count的值为:", count);7 console.log("flag的值为:", flag);8 }, [count, flag]);910 return (11 <>12 <h3>count 的值为:{count}</h3>13 <p>flag 的值为:{String(flag)}</p>14 <hr />15 <button onClick={() => setCount((prev) => prev + 1)}>点我+1</button>16 <button onClick={() => setFlag((prev) => !prev)}>Toggle</button>17 </>18 );19};
本意应该是单独监听count和flag的改变,然后执行count或flag相应的代码。但是改变count或flag之后,都输出了。所以应该分开写useEffect。
xxxxxxxxxx221export const Counter3: React.FC = () => {2 const [count, setCount] = useState(0);3 const [flag, setFlag] = useState(false);45 useEffect(() => {6 console.log("count的值为:", count);7 }, [count]);89 useEffect(() => {10 console.log("flag的值为:", flag);11 }, [flag]);1213 return (14 <>15 <h3>count 的值为:{count}</h3>16 <p>flag 的值为:{String(flag)}</p>17 <hr />18 <button onClick={() => setCount((prev) => prev + 1)}>点我+1</button>19 <button onClick={() => setFlag((prev) => !prev)}>Toggle</button>20 </>21 );22};
分开写useEffect之后,在组件初次渲染的时候,都会先执行一次,之后就是监听各自的依赖项来执行副作用函数了。
useLayoutEffect 和 useEffect 的使用方式很相似:
| hooks 名称 | 执行时机 | 执行过程 |
|---|---|---|
| useEffect | 在浏览器重新绘制屏幕之后触发 | 异步执行,不阻塞浏览器绘制 |
| useLayoutEffect | 在浏览器重新绘制屏幕之前触发 | 同步执行,阻塞浏览器重新绘制 |
注意:React 保证了
useLayoutEffect中的代码以及其中任何计划的状态更新都会在浏览器重新绘制屏幕之前得到处理。这里的“在浏览器重新绘制屏幕之前触发”要理解清楚,此时拿到的useState定义的值是最新的,只是执行时机是在浏览器重新绘制之前触发。
点击按钮,把 num 值设置为 0,当页面更新完成后,判断 num 是否等于 0,如果等于 0,则在 useEffect 中把 num 赋值为随机的数字:
xxxxxxxxxx161export const RandomNumber: React.FC = () => {2 const [num, setNum] = useState(Math.random() * 200)34 useEffect(() => {5 if (num === 0) {6 setNum(10 + Math.random() * 200)7 }8 }, [num])910 return (11 <>12 <h1>num 的值是:{num}</h1>13 <button onClick={() => setNum(0)}>重置 num</button>14 </>15 )16}运行上面的代码,我们会发现这串数字会出现闪烁的情况。原因是页面会先将 h1 渲染为 0,然后再渲染成随机的数字,由于更新的很快便出现了闪烁。下面的动图可能看的不是很清楚,原因是gif录制时的帧数不够,实际操作一遍,就很明显的看到抖动。

为了解决上述问题,可以把 useEffect 替换为 useLayoutEffect:
xxxxxxxxxx161export const RandomNumber: React.FC = () => {2 const [num, setNum] = useState(Math.random() * 200)34 useLayoutEffect(() => {5 if (num === 0) {6 setNum(10 + Math.random() * 200)7 }8 }, [num])910 return (11 <>12 <h1>num 的值是:{num}</h1>13 <button onClick={() => setNum(0)}>重置 num</button>14 </>15 )16}更改完成后再次运行代码,发现数字不再闪烁了。因为点击按钮时,num 更新为 0,但此时页面不会渲染,而是等待 useLayoutEffect 内部状态修改后才会更新页面,所以不会出现闪烁。
上面的这个例子好像没有实际作用、不会遇到,但是在实际编写代码的时候,也许不自觉的就造成了这种现象,所以遇到这种现象的时候,就要想到有
useLayoutEffect这个hooks,可以解决问题。